Chore/pin npm exactly - #10
Merged
Merged
Conversation
npm cannot attach a trusted publisher to a package that does not exist,
and offers no way to reserve a name, so the first publish must
authenticate with a token. The risk is not the bootstrap itself but that
the credential quietly becomes permanent.
So the workflow refuses the token path once the package exists on npmjs:
a release fails while NPM_TOKEN is still present. Cleanup stops being
something to remember and becomes something the pipeline enforces.
All four states are handled explicitly and were tested:
token + package absent -> publish, with a loud warning
token + package exists -> refuse, tell the operator to delete the secret
no token + absent -> refuse, explain why a token is needed
no token + exists -> OIDC, the steady state
Provenance is unaffected, which is the part worth knowing: --provenance
derives from the job's Sigstore identity via id-token, not from how npm
authenticates. v0.1.0 is therefore fully attested despite predating
trusted publishing on the package.
Dropped registry-url from setup-node. It writes an .npmrc line of
//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}, which resolves to an
empty token when no secret is set and would have made npm attempt token
auth instead of falling back to OIDC -- breaking every release after the
first. The registry comes from the tarball's own publishConfig.
SECURITY.md documents the bootstrap as a four-step procedure with the
token deliberately weak: granular, @matchory-scoped, 7-day expiry, stored
as a release-environment secret rather than a repository one so the
existing approval and tag gates already apply to it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
Audited the workflow against npm's documented guidance and against npm's own implementation. Three findings, two of them real defects. 1. The publish registry was not actually pinned publishConfig.registry does NOT beat an `@scope:registry` entry in an .npmrc, and libnpmpublish's ensureProvenanceGeneration validates the CI provider and the id-token but never the destination registry. A stray scope mapping could therefore have redirected a provenance-signed release to another registry, and npm would have signed it happily. Found empirically rather than by reading: `npm publish --dry-run` for this package resolved to npm.pkg.github.com, not npmjs, because a developer .npmrc maps the @matchory scope there. CI has no such file, so it would not have fired -- but "would not have fired" is not a control. --registry is now pinned on the publish and on the package-existence probe. That is strictly stronger than publishConfig or setup-node's registry-url, because nothing ambient can override it. 2. A prerelease would have been published as the default install The tag regex accepts prereleases, but --tag was hardcoded to latest, so v1.0.0-rc.1 would have become `latest` for every consumer. The dist-tag is now derived from the version: rc, beta, alpha, or next for an unrecognised identifier. GitHub releases are marked --prerelease to match. Tested across seven version shapes. 3. My stated reason for dropping setup-node's registry-url was wrong I claimed it writes an empty _authToken and breaks the OIDC fallback. npm's own documented workflow uses registry-url with no token at all, so that was an unverified assumption. It stays omitted, but for the correct reason: --registry on the command line cannot be overridden, whereas registry-url can. Also verified the build/publish split rather than assuming it. Provenance derives its subject from the tarball's own integrity digest and its build metadata from GITHUB_* environment variables, so publishing a pre-packed tarball generates provenance identically to publishing from a source directory. npm's documented workflow does not split build from publish, so this is a deliberate divergence, kept because it is what makes "publish cannot substitute content" true and because it matches PyPA's recommendation for PyPI. Recorded in SECURITY.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
Verified that npm has no equivalent of PyPI's pending publishers: a
trusted publisher is configured per-package at
npmjs.com/package/<name>/access, which does not exist until something has
been published. The catch-22 is real, so something must be published
before OIDC can be used at all.
Crossing that gap with a deprecated 0.0.0 placeholder is better than
bootstrapping with a CI token, which is what was documented before:
- no npm token ever enters this repository, at any point in its history
- 0.1.0, the first version anyone installs, is published entirely
through OIDC with provenance
The alternative would have put a short-lived token in a GitHub secret so
that 0.1.0 could be published from CI. That gets provenance on 0.1.0 but
accepts a credential in the repository. The placeholder trades a permanent
deprecated 0.0.0 for never holding a token, which is the better deal.
The placeholder is deliberately inert: a README and the licence, 1.3 kB,
three files. No real configuration ships without provenance, and it is
published under a `placeholder` dist-tag so it never becomes `latest`.
The token path stays in release.yml as a fallback for bootstrapping some
future package, and remains self-limiting: the workflow refuses to use a
token once the package exists.
Also documents the trap that `npm login`/`npm publish` need an explicit
--registry, because an @matchory:registry entry in a developer .npmrc
points the scope at GitHub Packages and beats publishConfig.registry.
Found while checking this: the npmjs token in the local .npmrc is expired,
so the placeholder has to be published after a fresh login.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.